{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "81a6f12e",
   "metadata": {},
   "source": [
    "下面几段代码展示朴素贝叶斯模型的训练和预测。这里使用的数据集为本书自制的Books数据集，包含约1万本图书的标题，分为3种主题。首先是预处理，针对文本分类的预处理主要包含以下步骤：\n",
    "\n",
    "- 通常可以将英文文本全部转换为小写，或者将中文内容全部转换为简体，等等，这一般不会改变文本内容。\n",
    "- 去除标点。英文中的标点符号和单词之间没有空格（如——“Hi, there!”），如果不去除标点，“Hi,”和“there!”会被识别为不同于“Hi”和“there”的两个词，这显然是不合理的。对于中文，移除标点一般也不会影响文本的内容。\n",
    "- 分词。中文汉字之间没有空格分隔，中文分词有时比英文分词更加困难，此处不再赘述。\n",
    "- 去除停用词（如“I”、“is”、“的”等）。这些词往往大量出现但没有具体含义。\n",
    "- 建立词表。通常会忽略语料库中频率非常低的词。\n",
    "- 将词转换为词表索引（ID），便于机器学习模型使用。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "5936ceb0",
   "metadata": {
    "scrolled": true
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "train size = 8627 , test size = 2157\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "100%|█████████████████████████████████████████████████████████████████████████████| 8627/8627 [00:41<00:00, 209.00it/s]\n",
      "100%|█████████████████████████████████████████████████████████████████████████████| 2157/2157 [00:10<00:00, 203.15it/s]"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "unique tokens = 6956, total counts = 54884, max freq = 1635, min freq = 1\n",
      "min_freq = 3, min_len = 2, max_size = None, remaining tokens = 1650, in-vocab rate = 0.7944209605713869\n",
      "['python', '编程', '入门', '教程']\n",
      "{'计算机类': 0, '艺术传媒类': 1, '经管类': 2}\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\n"
     ]
    }
   ],
   "source": [
    "import json\n",
    "import os\n",
    "import requests\n",
    "import re\n",
    "from tqdm import tqdm\n",
    "from collections import defaultdict\n",
    "from string import punctuation\n",
    "import spacy\n",
    "from spacy.lang.zh.stop_words import STOP_WORDS\n",
    "nlp = spacy.load('zh_core_web_sm')\n",
    "\n",
    "\n",
    "class BooksDataset:\n",
    "    def __init__(self):\n",
    "        train_file, test_file = 'train.jsonl', 'test.jsonl'\n",
    "\n",
    "        # 下载数据为JSON格式，转化为Python对象\n",
    "        def read_file(file_name):\n",
    "            with open(file_name, 'r', encoding='utf-8') as fin:\n",
    "                json_list = list(fin)\n",
    "            data_split = []\n",
    "            for json_str in json_list:\n",
    "                data_split.append(json.loads(json_str))\n",
    "            return data_split\n",
    "\n",
    "        self.train_data, self.test_data = read_file(train_file),\\\n",
    "            read_file(test_file)\n",
    "        print('train size =', len(self.train_data), \n",
    "              ', test size =', len(self.test_data))\n",
    "        \n",
    "        # 建立文本标签和数字标签的映射\n",
    "        self.label2id, self.id2label = {}, {}\n",
    "        for data_split in [self.train_data, self.test_data]:\n",
    "            for data in data_split:\n",
    "                txt = data['class']\n",
    "                if txt not in self.label2id:\n",
    "                    idx = len(self.label2id)\n",
    "                    self.label2id[txt] = idx\n",
    "                    self.id2label[idx] = txt\n",
    "                label_id = self.label2id[txt]\n",
    "                data['label'] = label_id\n",
    "\n",
    "    def tokenize(self, attr='book'):\n",
    "        # 使用以下两行命令安装spacy用于中文分词\n",
    "        # pip install -U spacy\n",
    "        # python -m spacy download zh_core_web_sm\n",
    "        # 去除文本中的符号和停用词\n",
    "        for data_split in [self.train_data, self.test_data]:\n",
    "            for data in tqdm(data_split):\n",
    "                # 转为小写\n",
    "                text = data[attr].lower()\n",
    "                # 符号替换为空\n",
    "                tokens = [t.text for t in nlp(text) \\\n",
    "                    if t.text not in STOP_WORDS]\n",
    "                # 这一步比较耗时，因此把tokenize的结果储存起来\n",
    "                data['tokens'] = tokens\n",
    "\n",
    "    # 根据分词结果建立词表，忽略部分低频词，\n",
    "    # 可以设置词最短长度和词表最大大小\n",
    "    def build_vocab(self, min_freq=3, min_len=2, max_size=None):\n",
    "        frequency = defaultdict(int)\n",
    "        for data in self.train_data:\n",
    "            tokens = data['tokens']\n",
    "            for token in tokens:\n",
    "                frequency[token] += 1 \n",
    "\n",
    "        print(f'unique tokens = {len(frequency)}, '+\\\n",
    "              f'total counts = {sum(frequency.values())}, '+\\\n",
    "              f'max freq = {max(frequency.values())}, '+\\\n",
    "              f'min freq = {min(frequency.values())}')    \n",
    "\n",
    "        self.token2id = {}\n",
    "        self.id2token = {}\n",
    "        total_count = 0\n",
    "        for token, freq in sorted(frequency.items(),\\\n",
    "            key=lambda x: -x[1]):\n",
    "            if max_size and len(self.token2id) >= max_size:\n",
    "                break\n",
    "            if freq > min_freq:\n",
    "                if (min_len is None) or (min_len and \\\n",
    "                    len(token) >= min_len):\n",
    "                    self.token2id[token] = len(self.token2id)\n",
    "                    self.id2token[len(self.id2token)] = token\n",
    "                    total_count += freq\n",
    "            else:\n",
    "                break\n",
    "        print(f'min_freq = {min_freq}, min_len = {min_len}, '+\\\n",
    "              f'max_size = {max_size}, '\n",
    "              f'remaining tokens = {len(self.token2id)}, '\n",
    "              f'in-vocab rate = {total_count / sum(frequency.values())}')\n",
    "\n",
    "    # 将分词后的结果转化为数字索引\n",
    "    def convert_tokens_to_ids(self):\n",
    "        for data_split in [self.train_data, self.test_data]:\n",
    "            for data in data_split:\n",
    "                data['token_ids'] = []\n",
    "                for token in data['tokens']:\n",
    "                    if token in self.token2id:\n",
    "                        data['token_ids'].append(self.token2id[token])\n",
    "\n",
    "        \n",
    "dataset = BooksDataset()\n",
    "dataset.tokenize()\n",
    "dataset.build_vocab()\n",
    "print(dataset.train_data[0]['tokens'])\n",
    "print(dataset.label2id)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "26d79e05",
   "metadata": {},
   "source": [
    "完成分词后，对出现次数超过3次的词元建立词表，并将分词后的文档转化为词元id的序列。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "0d6b1918",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "unique tokens = 6956, total counts = 54884, max freq = 1635, min freq = 1\n",
      "min_freq = 3, min_len = 2, max_size = None, remaining tokens = 1650, in-vocab rate = 0.7944209605713869\n",
      "[18, 26, 5, 0]\n"
     ]
    }
   ],
   "source": [
    "dataset.build_vocab(min_freq=3)\n",
    "dataset.convert_tokens_to_ids()\n",
    "print(dataset.train_data[0]['token_ids'])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d096d95f",
   "metadata": {},
   "source": [
    "接下来将数据和标签准备成便于训练的矩阵格式。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "ba632265",
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "\n",
    "train_X, train_Y = [], []\n",
    "test_X, test_Y = [], []\n",
    "\n",
    "for data in dataset.train_data:\n",
    "    x = np.zeros(len(dataset.token2id), dtype=np.int32)\n",
    "    for token_id in data['token_ids']:\n",
    "        x[token_id] += 1\n",
    "    train_X.append(x)\n",
    "    train_Y.append(data['label'])\n",
    "for data in dataset.test_data:\n",
    "    x = np.zeros(len(dataset.token2id), dtype=np.int32)\n",
    "    for token_id in data['token_ids']:\n",
    "        x[token_id] += 1\n",
    "    test_X.append(x)\n",
    "    test_Y.append(data['label'])\n",
    "train_X, train_Y = np.array(train_X), np.array(train_Y)\n",
    "test_X, test_Y = np.array(test_X), np.array(test_Y)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3938acdb",
   "metadata": {},
   "source": [
    "下面代码展示朴素贝叶斯的训练和预测。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "f13251b7",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "P(计算机类) = 0.4453460067230787\n",
      "P(艺术传媒类) = 0.26660484525327466\n",
      "P(经管类) = 0.2880491480236467\n",
      "P(教程|计算机类) = 0.5726495726495726\n",
      "P(基础|计算机类) = 0.6503006012024048\n",
      "P(设计|计算机类) = 0.606694560669456\n",
      "test example-0, prediction = 0, label = 0\n",
      "test example-1, prediction = 0, label = 0\n",
      "test example-2, prediction = 1, label = 1\n",
      "test example-3, prediction = 1, label = 1\n",
      "test example-4, prediction = 1, label = 1\n"
     ]
    }
   ],
   "source": [
    "import numpy as np\n",
    "\n",
    "class NaiveBayes:\n",
    "    def __init__(self, num_classes, vocab_size):\n",
    "        self.num_classes = num_classes\n",
    "        self.vocab_size = vocab_size\n",
    "        self.prior = np.zeros(num_classes, dtype=np.float64)\n",
    "        self.likelihood = np.zeros((num_classes, vocab_size),\\\n",
    "            dtype=np.float64)\n",
    "        \n",
    "    def fit(self, X, Y):\n",
    "        # NaiveBayes的训练主要涉及先验概率和似然的估计，\n",
    "        # 这两者都可以通过计数简单获得\n",
    "        for x, y in zip(X, Y):\n",
    "            self.prior[y] += 1\n",
    "            for token_id in x:\n",
    "                self.likelihood[y, token_id] += 1\n",
    "                \n",
    "        self.prior /= self.prior.sum()\n",
    "        # laplace平滑\n",
    "        self.likelihood += 1\n",
    "        self.likelihood /= self.likelihood.sum(axis=0)\n",
    "        # 为了避免精度溢出，使用对数概率\n",
    "        self.prior = np.log(self.prior)\n",
    "        self.likelihood = np.log(self.likelihood)\n",
    "    \n",
    "    def predict(self, X):\n",
    "        # 算出各个类别的先验概率与似然的乘积，找出最大的作为分类结果\n",
    "        preds = []\n",
    "        for x in X:\n",
    "            p = np.zeros(self.num_classes, dtype=np.float64)\n",
    "            for i in range(self.num_classes):\n",
    "                p[i] += self.prior[i]\n",
    "                for token in x:\n",
    "                    p[i] += self.likelihood[i, token]\n",
    "            preds.append(np.argmax(p))\n",
    "        return preds\n",
    "\n",
    "nb = NaiveBayes(len(dataset.label2id), len(dataset.token2id))\n",
    "train_X, train_Y = [], []\n",
    "for data in dataset.train_data:\n",
    "    train_X.append(data['token_ids'])\n",
    "    train_Y.append(data['label'])\n",
    "nb.fit(train_X, train_Y)\n",
    "\n",
    "for i in range(3):\n",
    "    print(f'P({dataset.id2label[i]}) = {np.exp(nb.prior[i])}')\n",
    "for i in range(3):\n",
    "    print(f'P({dataset.id2token[i]}|{dataset.id2label[0]}) = '+\\\n",
    "          f'{np.exp(nb.likelihood[0, i])}')\n",
    "\n",
    "test_X, test_Y = [], []\n",
    "for data in dataset.test_data:\n",
    "    test_X.append(data['token_ids'])\n",
    "    test_Y.append(data['label'])\n",
    "    \n",
    "NB_preds = nb.predict(test_X)\n",
    "    \n",
    "for i, (p, y) in enumerate(zip(NB_preds, test_Y)):\n",
    "    if i >= 5:\n",
    "        break\n",
    "    print(f'test example-{i}, prediction = {p}, label = {y}')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a1cf6399",
   "metadata": {},
   "source": [
    "下面使用第3章介绍的TF-IDF方法得到文档的特征向量，并使用PyTorch实现逻辑斯谛回归模型的训练和预测。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "21a3bc79",
   "metadata": {
    "scrolled": true
   },
   "outputs": [],
   "source": [
    "import os\n",
    "import sys\n",
    "\n",
    "class TFIDF:\n",
    "    def __init__(self, vocab_size, norm='l2', smooth_idf=True,\\\n",
    "                 sublinear_tf=True):\n",
    "        self.vocab_size = vocab_size\n",
    "        self.norm = norm\n",
    "        self.smooth_idf = smooth_idf\n",
    "        self.sublinear_tf = sublinear_tf\n",
    "    \n",
    "    def fit(self, X):\n",
    "        doc_freq = np.zeros(self.vocab_size, dtype=np.float64)\n",
    "        for data in X:\n",
    "            for token_id in set(data):\n",
    "                doc_freq[token_id] += 1\n",
    "        doc_freq += int(self.smooth_idf)\n",
    "        n_samples = len(X) + int(self.smooth_idf)\n",
    "        self.idf = np.log(n_samples / doc_freq) + 1\n",
    "    \n",
    "    def transform(self, X):\n",
    "        assert hasattr(self, 'idf')\n",
    "        term_freq = np.zeros((len(X), self.vocab_size), dtype=np.float64)\n",
    "        for i, data in enumerate(X):\n",
    "            for token in data:\n",
    "                term_freq[i, token] += 1\n",
    "        if self.sublinear_tf:\n",
    "            term_freq = np.log(term_freq + 1)\n",
    "        Y = term_freq * self.idf\n",
    "        if self.norm:\n",
    "            row_norm = (Y**2).sum(axis=1)\n",
    "            row_norm[row_norm == 0] = 1\n",
    "            Y /= np.sqrt(row_norm)[:, None]\n",
    "        return Y\n",
    "    \n",
    "    def fit_transform(self, X):\n",
    "        self.fit(X)\n",
    "        return self.transform(X)\n",
    "        \n",
    "tfidf = TFIDF(len(dataset.token2id))\n",
    "tfidf.fit(train_X)\n",
    "train_F = tfidf.transform(train_X)\n",
    "test_F = tfidf.transform(test_X)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dc8af30b",
   "metadata": {},
   "source": [
    "逻辑斯谛回归可以看作一个一层的神经网络模型，使用PyTorch实现可以方便地利用自动求导功能。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "1ddebf0c",
   "metadata": {
    "scrolled": false
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "epoch-49, loss=0.2495: 100%|█| 50/50 [00:10<00:00,  4.77it/s\n"
     ]
    },
    {
     "data": {
      "image/png": "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",
      "text/plain": [
       "<Figure size 640x480 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "eval_loss = 0.2870\n"
     ]
    }
   ],
   "source": [
    "import torch\n",
    "from torch import nn\n",
    "\n",
    "class LR(nn.Module):\n",
    "    def __init__(self, input_dim, output_dim):\n",
    "        super(LR, self).__init__()\n",
    "        self.linear = nn.Linear(input_dim, output_dim)\n",
    "        \n",
    "    def forward(self, input_feats, labels=None):\n",
    "        outputs = self.linear(input_feats)\n",
    "        \n",
    "        if labels is not None:\n",
    "            loss_fc = nn.CrossEntropyLoss()\n",
    "            loss = loss_fc(outputs, labels)\n",
    "            return (loss, outputs)\n",
    "        \n",
    "        return outputs\n",
    "\n",
    "model = LR(len(dataset.token2id), len(dataset.label2id))\n",
    "\n",
    "from torch.utils.data import Dataset, DataLoader\n",
    "from torch.optim import SGD, Adam\n",
    "\n",
    "# 使用PyTorch的DataLoader来进行数据循环，因此按照PyTorch的接口\n",
    "# 实现myDataset和DataCollator两个类\n",
    "# myDataset是对特征向量和标签的简单封装便于对齐接口，\n",
    "# DataCollator用于批量将数据转化为PyTorch支持的张量类型\n",
    "class myDataset(Dataset):\n",
    "    def __init__(self, X, Y):\n",
    "        self.X = X\n",
    "        self.Y = Y\n",
    "        \n",
    "    def __len__(self):\n",
    "        return len(self.X)\n",
    "\n",
    "    def __getitem__(self, idx):\n",
    "        return (self.X[idx], self.Y[idx])\n",
    "\n",
    "class DataCollator:\n",
    "    @classmethod\n",
    "    def collate_batch(cls, batch):\n",
    "        feats, labels = [], []\n",
    "        for x, y in batch:\n",
    "            feats.append(x)\n",
    "            labels.append(y)\n",
    "        # 直接将一个ndarray的列表转化为张量是非常慢的，\n",
    "        # 所以需要提前将列表转化为一整个ndarray\n",
    "        feats = torch.tensor(np.array(feats), dtype=torch.float)\n",
    "        labels = torch.tensor(np.array(labels), dtype=torch.long)\n",
    "        return {'input_feats': feats, 'labels': labels}\n",
    "\n",
    "# 设置训练超参数和优化器，模型初始化\n",
    "epochs = 50\n",
    "batch_size = 128\n",
    "learning_rate = 1e-3\n",
    "weight_decay = 0\n",
    "\n",
    "train_dataset = myDataset(train_F, train_Y)\n",
    "test_dataset = myDataset(test_F, test_Y)\n",
    "\n",
    "data_collator = DataCollator()\n",
    "train_dataloader = DataLoader(train_dataset, batch_size=batch_size,\\\n",
    "    shuffle=True, collate_fn=data_collator.collate_batch)\n",
    "test_dataloader = DataLoader(test_dataset, batch_size=batch_size,\\\n",
    "    shuffle=False, collate_fn=data_collator.collate_batch)\n",
    "optimizer = Adam(model.parameters(), lr=learning_rate,\\\n",
    "    weight_decay=weight_decay)\n",
    "model.zero_grad()\n",
    "model.train()\n",
    "\n",
    "from tqdm import tqdm, trange\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "# 模型训练\n",
    "with trange(epochs, desc='epoch', ncols=60) as pbar:\n",
    "    epoch_loss = []\n",
    "    for epoch in pbar:\n",
    "        model.train()\n",
    "        for step, batch in enumerate(train_dataloader):\n",
    "            loss = model(**batch)[0]\n",
    "            pbar.set_description(f'epoch-{epoch}, loss={loss.item():.4f}')\n",
    "            loss.backward()\n",
    "            optimizer.step()\n",
    "            model.zero_grad()\n",
    "            epoch_loss.append(loss.item())\n",
    "\n",
    "    epoch_loss = np.array(epoch_loss)\n",
    "    # 打印损失曲线\n",
    "    plt.plot(range(len(epoch_loss)), epoch_loss)\n",
    "    plt.xlabel('training epoch')\n",
    "    plt.ylabel('loss')\n",
    "    plt.show()\n",
    "    \n",
    "    model.eval()\n",
    "    with torch.no_grad():\n",
    "        loss_terms = []\n",
    "        for batch in test_dataloader:\n",
    "            loss = model(**batch)[0]\n",
    "            loss_terms.append(loss.item())\n",
    "        print(f'eval_loss = {np.mean(loss_terms):.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "10808854",
   "metadata": {},
   "source": [
    "下面的代码使用训练好的模型对测试集进行预测，并报告分类结果。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "11a9bf62",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "test example-0, prediction = 0, label = 0\n",
      "test example-1, prediction = 0, label = 0\n",
      "test example-2, prediction = 1, label = 1\n",
      "test example-3, prediction = 1, label = 1\n",
      "test example-4, prediction = 1, label = 1\n"
     ]
    }
   ],
   "source": [
    "LR_preds = []\n",
    "model.eval()\n",
    "for batch in test_dataloader:\n",
    "    with torch.no_grad():\n",
    "        _, preds = model(**batch)\n",
    "        preds = np.argmax(preds, axis=1)\n",
    "        LR_preds.extend(preds)\n",
    "            \n",
    "for i, (p, y) in enumerate(zip(LR_preds, test_Y)):\n",
    "    if i >= 5:\n",
    "        break\n",
    "    print(f'test example-{i}, prediction = {p}, label = {y}')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c5feb65e",
   "metadata": {},
   "source": [
    "下面的代码展示多分类情况下宏平均和微平均的算法。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "a5ac32c5",
   "metadata": {
    "scrolled": false
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "NB: micro-f1 = 0.8961520630505331, macro-f1 = 0.8948572078813896\n",
      "LR: micro-f1 = 0.9142327306444136, macro-f1 = 0.9135849035811788\n"
     ]
    }
   ],
   "source": [
    "test_Y = np.array(test_Y)\n",
    "NB_preds = np.array(NB_preds)\n",
    "LR_preds = np.array(LR_preds)\n",
    "\n",
    "def micro_f1(preds, labels):\n",
    "    TP = np.sum(preds == labels)\n",
    "    FN = FP = 0\n",
    "    for i in range(len(dataset.label2id)):\n",
    "        FN += np.sum((preds == i) & (labels != i))\n",
    "        FP += np.sum((preds != i) & (labels == i))\n",
    "    precision = TP / (TP + FP)\n",
    "    recall = TP / (TP + FN)\n",
    "    f1 = 2 * precision * recall / (precision + recall)\n",
    "    return f1\n",
    "\n",
    "def macro_f1(preds, labels):\n",
    "    f_scores = []\n",
    "    for i in range(len(dataset.label2id)):\n",
    "        TP = np.sum((preds == i) & (labels == i))\n",
    "        FN = np.sum((preds == i) & (labels != i))\n",
    "        FP = np.sum((preds != i) & (labels == i))\n",
    "        precision = TP / (TP + FP)\n",
    "        recall = TP / (TP + FN)\n",
    "        f1 = 2 * precision * recall / (precision + recall)\n",
    "        f_scores.append(f1)\n",
    "    return np.mean(f_scores)\n",
    "\n",
    "print(f'NB: micro-f1 = {micro_f1(NB_preds, test_Y)}, '+\\\n",
    "      f'macro-f1 = {macro_f1(NB_preds, test_Y)}')\n",
    "print(f'LR: micro-f1 = {micro_f1(LR_preds, test_Y)}, '+\\\n",
    "      f'macro-f1 = {macro_f1(LR_preds, test_Y)}')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1482c7d1",
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.11.4"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
